YownYang's blog

译《Effective Objective-C 2.0》第二章

这是翻译《Effective Objective-C 2.0》的第二章:对象、消息、运行时

简介

Objective-C等面向对象的语言中,对象就是基石,提供数据存储和传递的功能。消息是在过程中对象之间进行数据传递和执行操作。深入理解这些功能如何工作对于构建高效和可维护代码是至关重要的。

当程序运行后,Objective-C中的runtime为其提供相关支持。runtime提供了关键的函数使对象之间可以传递消息以及创建类实例的所有逻辑。理解这一切如何工作会使你成为一个更好的开发者。

理解属性

属性是Objective-C的一个功能,用于对象对数据的封装。Objective-C对象通常会把它们所需要的数据保存为各种实例变量。实例变量的访问通常通过存取方法。getter方法用来读取变量,setter方法用来设置变量。这个概念是标准的,并且通过属性这个功能成为了Objective-C 2.0的一部分,这让开发者令编译器去自动生成读写方法。这个功能引入了一个新语法即点语法,通过点语法访问数据存储可以减少代码的冗长。你可能已经使用了属性,但你可能不知道它所有的功能。而且,还有很多与之相关的问题。第6节主要说明哪些问题可以通过属性解决并且指出主要的功能。

用一个类去表示一个人的信息可能要存储名字,出生日期,地址等等。你可能这样在一个类的公共接口中声明这些变量:

1
2
3
4
5
6
7
8
9
10
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject {
@public
NSString *_firstName;
NSString *_lastName;
@private
NSString *_someInternalData;
}
@end

如果你从事过Java或者C++开发,这种写法是熟悉的,你可以定义变量的作用域。然而,这种技术在Objective-C中很少使用。这种方法的问题是在编译时就定义了对象的布局。无论任何时候访问_firstName变量,都会通过编译器偏移硬编码去访问存储对象的内存空间。如果你不在_firstName之前添加任何变量这样做都是没问题的。例如:假设在_firstName之前添加一个变量:


译者言:由于对象布局在编译时已定,对象内存偏移量自然固定,此处硬编码代指偏移量。


1
2
3
4
5
6
7
8
9
10
11
12
13
#import <Foundation/Foundation.h>
@interface EOCPerson : NSObject {
@public
NSDate *_dateOfBirth;
NSString *_firstName;
NSString *_lastName;
@private
NSString *_someInternalData;
}
@end

之前的偏移量代表_firstName而现在代表_dateOfBirth了。任何通过硬编码读取的地方都将读取到一个错误的值。为了说明这一点,假设指针是4个字节,图2.1分别展示了添加_dateOfBirth变量之前和之后的类的内存布局。

Figure 2.1 添加变量前类的布局和添加变量后类的布局

当类定义发生变化时,如果代码使用了编译时的偏移量那么将出现问题,除非重新编译。例如,一个代码库中的代码使用了旧的类定义。如果链接的代码使用了新的类定义,那么在运行时将出现不兼容的情况。为了解决这个问题,各种语言都提出了自己的解决办法。Objective-C的做法是,将实例变量看做特殊的变量,由类变量(第14节详细的讲述了类对象)去存储它的偏移量。在运行时,会去查找偏移量,当类定义发生改变,偏移量也随之改变。这样无论如何访问一个变量,都会使用正确的偏移量。你甚至可以在运行期间给类添加实例变量。这就是稳固的ABI(应用程序二进制接口)。ABI定义了许多内容,其中一项是生成代码时的规则。稳固的ABI也意味着你可以在类扩展中或者实现文件中定义实例变量。因此,你不需要在接口文件中声明所有的实例变量,因此你不需要在公共接口中泄露任何你的内部实现信息。

另一种解决这个问题的办法是使用存取方法而不是直接访问实例变量。虽然属性最终仍是由实例变量实现的,但是属性提供了一种简洁的抽象。你可以自己编写存取方法,但是在标准的Objective-C代码格式中,存取方法遵循严格的命名规则。因为严格的命名,Objective-C才能根据变量名自动创建存取方法。这就是@property语法的来源。

在对象接口的定义中使用@property,这是一种标准的写法,以提供对象的存取方法。因此,可以把属性当做一种简称,通过它去访问一个给定类型和给定名字的变量。例如,考虑下面的代码:

1
2
3
4
@interface EOCPerson : NSObject
@property NSString *firstName;
@property NSString *lastName;
@end

对于类的使用者,上面的代码是等价于下面的代码的:

1
2
3
4
5
6
@interface EOCPerson : NSObject
- (NSString *)firstName;
- (void)setFirstName:(NSString *)firstName;
- (NSString *)lastName;
- (void)setLastName:(NSString *)lastName;
@end

使用属性,你可以使用点语法。在C中你访问栈结构体的成员也是使用类似语法。编译器会将点语法转化为存取方法,与你直接调用是一样的。因此,使用点语法和直接调用时没有任何差异的。下面展示了等价的代码:

1
2
3
4
5
6
7
EOCPerson *aPerson = [EOCPerson new];
aPerson.firstName = @"Bob"; // Same as;
[aPerson setFirstName:@"Bob"];
NSString *lastName = aPerson.lastName; // Same as;
NSString *lastName = [aPerson lastName];

属性的好处还不止这些。如果你使用它,编译器将会通过一个叫做自动合成的功能生成那些方法代码。需要强调的是,编译器会在编译时自动生成代码,所以你在编辑器中是看不到自动合成的方法的。除了自动生成上述代码,编译器也会自动生成一个合适类型的变量,并且会在名字前面加下划线。在前面的代码中,他自动生成了两个变量:_firstName_lastName。通过在类实现中使用@synthesize语法,这两个实例变量名字是可以控制的,像这样:

1
2
3
4
@implementation EOCPerson
@synthesize firstName = _myFirstName;
@synthesize lastName = _myLastName;
@end

使用上面的语法会产生两个实例变量,分别叫做_myFirstName_myLastName,用于代替默认生成的。不过一般不会去改变默认的变量名;然而如果你不喜欢使用下划线去命名变量,你可以使用这个方法去设置你想要的。但是我建议你使用默认的命名规则,如果每个人都遵循这个规则,那么每个人读代码都是容易理解的。

如果你不想编译器给你自动生成存取方法,你可以自己去实现这些方法。然而,如果你仅实现了存取方法中的一个,那么编译器仍会自动生成另一个方法。另一种阻止它自动生成的办法是使用@dynamic关键字,这会告诉编译器不要自动生成实例变量返回给属性并且不会自动生成存取方法。而且,当编译代码访问这个属性时,编译器将会忽略实际上存取方法还没有定义的情况,并且相信它在运行时是可以使用的。例如,如果一个类继承自NSManagedObject类,它的存取方法需要在运行时动态创建。NSManagedObject类之所以这样做是因为子类的属性不是实例变量。它的数据来源于后台数据库。例如:

1
2
3
4
5
6
7
8
@interface EOCPerson : NSManagedObject
@property NSString *firstName;
@property NSString *lastName;
@end
@implementation EOCPerson
@dynamic firstName, lastName;
@end

在这个类中,编译器不会自动生成存取方法或者实例变量。如果你尝试去访问某个属性,编译器也没有警告信息。

属性特质

属性的另一个问题是你应该知道它所有的特质。你可以通过它去影响编译器生成的存取方法。例如下面这个属性使用了三个特质:

1
@property (nonatomic, readwrite, copy) NSString *firstName;

属性可以使用4类特质。

原子性

通常,生成的存取方法包含锁去保持原子性。如果你设置了nonatomic特质,那么就不会有锁了。请注意,尽管没有atomic特质(atomic特质是由你不设置nonatomic特质得来的),但是你仍何以在属性特质中写上,并且编译器不会报错。如果你自己实现存取方法,你应该指定与其相符的原子性。

读写权限

  • readwrite 读写权限时,gettersetter方法都是可用的。如果属性是自动合成的,那么编译器将会自动生成两个方法。
  • readonly 只读权限时,只有getter方法是可用的,如果属性是自动合成的,那么编译器将只生成getter方法。当你想暴漏一个只读属性给外部,并且需要在内部重新定义它为可读写时,你可以使用它。第27节讲了更多内容。

内存管理语义

属性用于封装数据,数据需要有具体的所有权。它仅仅影响setter方法。例如,用setter方法设置一个值时,它是应该保持新值还是将其直接赋给底层实例变量?当编译器自动生成存取方法时,它要取决于这些特质去生成代码。如果你自己实现存取方法,你应该指定与其相符的特质。

  • assign 它的setter方法只会简单的给标量类型的值赋值,例如CGFloat或者NSInteger
  • strong 这种特质表示定义了一个拥有关系。当为这个属性赋值时,首先持有新值,接着释放旧值,然后将新值赋给这个属性。
  • weak 这种特质表示定义了一个非拥有关系。当为这个属性赋值时,它是不持有新值的;也不释放旧值。它是类似于assign特质的,但当目标对象释放时,它的值会被自动置为nil。
  • unsafe_unretained 它同assign语义相似,但是它适用于对象类型,它表达了一个非拥有关系,当目标对象销毁时,它不会自动置为nil,这点与weak是有区别的。
  • copy 这种特质类似strong特质,定义一个拥有关系;然而,它是用拷贝替代持有新值的。当属性是类似与NSString *时,经常用此特质保证其封装性,因为可能通过setter方法给予其一个可变值。如果赋的值是可变的,那么这个属性的类型可能就在对象不知道的情况下改变。所以就需要使用copy特质去使对象中的字符串不会在无意中被改变。任何需要保持不可变的对象都应该使用copy去修饰。

方法名

通过使用下面的特质可以控制存取方法的名字:

  • getter= 指定getter的名字。当你想给一个Boolean属性加上is前缀时,通常使用这个方法。例如,在UISwitch类中,表示状态开关的属性就是这样定义的:

    1
    @property(nonatomic,getter=isOn) BOOL on;
  • setter= 指定setter的名字。这个方法不常用的。

你可以通过这些特质细微的控制自动生成的存取方法。然而,需要谨记的是,如果你实现了自己的存取方法,你应该遵循指定的特质。例如,一个属性使用了copy特质,你需要在setter中拷贝它。否则,会误导属性的使用者。而且,如果不遵守这个约定,那么将会产生bug。

即使你可以通过别的方法设置属性,你也要遵循定义时的特质。例如,考虑扩充下EOCPerson类。属性声明时,使用了copy特质,因为它可能是可变的。这个类也增加了一个初始化方法,用于设置名和姓的值:

1
2
3
4
5
6
7
8
9
@interface EOCPerson : NSManagedObject
@property (copy) NSString *firstName;
@property (copy) NSString *lastName;
- (id)initWithFirstName:(NSString *)firstName
lastName:(NSString *)lastName;
@end

在实现自定义初始化方法时,遵循定义时的copy语义是非常重要的。因为属性定义就像类和对象之间的协议一样。所以初始化的代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
- (id)initWithFirstName:(NSString *)firstName lastName:(NSString *)lastName {
if ((self = [super init])) {
_firstName = [firstName copy];
_lastName = [lastName copy];
}
return self;
}

你可能会问为什么不简单的使用属性的setter方式去设置,如果总是使用setter设置,那将会保证属性的正确设置。你永远不该在init方法中使用存取方法,具体请看第7节。

如果你已经读了第18节,你应该知道,最好使对象不可变。将上述内容应用在EOCPerson类中,你需要设置两个属性为readonly。在初始化中设置它们的值,然后它们将不能被修改。在本例中,对你使用的值使用内存管理语义是重要的。所以属性定义的代码是这样的:

1
2
@property (copy, readonly) NSString *firstName;
@property (copy, readonly) NSString *lastName;

因为是只读属性,所以编译器不会为其自动生成setter方法。这样做是重要的,可以表明在初始化时设置了这两个属性值。没有这样的声明,使用这个类的人就可能不知道已经在init方法中使用了copy,因此他们可能会在调用初始化方法之前自行拷贝。这种操作是多余且低效的。

如果你想知道atomicnonatomic的区别。前面说过,使用了atomic的属性的存取方法会自动加锁确保原子性。这个意思是如果两个线程同时对属性进行读写操作,这个值不论在任何时候始终是有效的。如果不加所得情况下,当一个线程正在进行修改时,另一个线程进行读取,可能会将其未修改完的值读出来。如果发生了这种情况,读到的值可能是无效的。

如果你是在iOS中开发,那么你会注意到所有属性声明为nonatomic。这样做的历史原因是,加锁消耗比较大,可能会产生性能问题。通常,原子性并不是必须的,因为它不能确保线程安全,需要更深层次的锁定机制才能保证其线程安全。例如,即使使用了原子性,一个线程在连续读取某个属性值时,另一个线程修改了这个属性,仍将不能确保读到的是正确的值。因此,在iOS开发中你将一直使用nonatomic去修饰属性。但是在Mac OS X中,你不需要担心atomic带来的性能问题。

小结

  • @property语法提供了一种对象封装数据的定义。
  • 使用正确的特质提供数据存储。
  • 在设置属性所对应的实例变量时,需要遵循该属性的语义。
  • 在iOS中使用nonatomic,因为它会严重消耗性能。

在对象内部直接访问实例变量

属性总是用于访问外部对象的实例变量,但是在Objective-C社区中如何访问内部变量却是争执不休的。有些建议仍使用属性去访问实例变量,有些建议直接访问实例变量,有些两者混用。作者强烈推荐在读取实例变量时直接访问而在设置值时使用属性访问。

考虑下面的代码:

1
2
3
4
5
6
7
8
@interface EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
// Convenience for firstName + " " + lastName:
- (NSString*)fullName;
- (void)setFullName:(NSString*)fullName;
@end

这两个便捷方法fullNamesetFullName可能是这样实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (NSString*)fullName {
return [NSString stringWithFormat:@"%@ %@",
self.firstName, self.lastName];
}
/** The following assumes all full names have exactly 2
* parts. The method could be rewritten to support more
* exotic names.
*/
- (void)setFullName:(NSString*)fullName {
NSArray *components =
[fullName componentsSeparatedByString:@" "];
self.firstName = [components objectAtIndex:0];
self.lastName = [components objectAtIndex:1];
}

settergetter中,我们使用点语法访问实例变量。假设现在重写settergetter方法直接访问变量:

1
2
3
4
5
6
7
8
9
10
11
- (NSString*)fullName {
return [NSStringstringWithFormat:@"%@ %@",
_firstName, _lastName];
}
- (void)setFullName:(NSString*)fullName {
NSArray *components =
[fullName componentsSeparatedByString:@" "];
_firstName = [components objectAtIndex:0];
_lastName = [components objectAtIndex:1];
}

这两种方式是有些许差异的:

  • 毫无疑问直接访问实例变量是更快的,因为它不需要通过Objective-C方法派发(看第11节)。编译器通过代码直接访问存储对象实例的内存空间。
  • 直接调用实例变量会绕过setter的内存管理语义。例如,你的属性声明了copy特质,直接调用实例变量不会发生copy事件。新的值将会被保存,旧的值将会被释放。
  • 当直接访问实例变量时,KVO不会被触发。这可能会是一个问题,不过这主要取决你对这个对象的行为。
  • 通过属性访问可以更轻易的定位到与这个属性有关的问题,因为你可以给settergetter方法添加断点去看谁在什么时候访问了这个属性。

一个好的折中的办法是在写时通过setter方法设置,读时直接调用实例变量。这样做可以在读时有更高的效率,在写时也不会失去控制。最重要的是通过setter方法去写将保证你遵循内存管理语义。然而,这样做会有一些小的问题。

第一个问题是当你的值是在初始化方法中设置的时候。这里,你应该一直使用直接调用实例变量的方法,因为子类可能重写了它的setter方法。考虑下EOCPerson有一个叫做EOCSmithPerson的子类,这个类是专门用于表示名字叫smith的。这个子类可能会覆盖lastNamesetter方法:

1
2
3
4
5
6
7
- (void)setLastName:(NSString*)lastName {
if (![lastName isEqualToString:@"Smith"]) {
[NSException raise:NSInvalidArgumentException
format:@"Last name must be Smith"];
}
self.lastName = lastname;
}

EOCPerson基类可能在初始化时将名字设为空字符。如果它通过setter方法去这样设置,那么子类的setter将会被调用并且会抛出一个异常。然而,在有一些情况中,你必须在初始化时使用setter方法。当实例变量是声明在一个父类中的时候;你不能通过直接调用实例变量时,那么你必须使用setter方法。

另一个问题是当属性使用懒加载时。在这种情况下,你必须通过getter访问;如果没有,那个实例变量将永远不会初始化。例如,EOCPerson类可能有一个属性通过一个复杂对象去代表人脑。如果这个属性很少用并且初始化成本较高的,你可能在getter中使用懒加载,像这样:

1
2
3
4
5
6
- (EOCBrain*)brain {
if (!_brain) {
_brain = [Brain new];
}
return _brain;
}

如果你直接调用实例变量且没有调用过getter方法,大脑这个属性永远不会创建,所以你需要通过属性的存取方法去访问它。

小结

  • 在内部使用数据时,通过直接调用实例变量来读,通过调用属性来写。
  • 在初始化和销毁中,不论读还是写都通过实例变量直接访问。
  • 当你使用了懒加载时,你需要使用属性去读。

理解对象的等同性

比较对象相等是非常有用的。然而,使用等于操作符去判断相等通常不是你想做的,因为这样做比较的是它们的指针本身,而不是指针指向的对象。相应的,你应该使用声明在NSObject协议中的isEqual:方法去检验两个对象是否相等。通常情况下,两个不同类的对象总是不相等的。如果你已经知道你要检查的两个对象是同一个类,那么你可以使用它们自己提供的相同性检测方法。例如,下面的代码:

1
2
3
4
5
NSString *foo = @"Badger 123";
NSString *bar = [NSString stringWithFormat:@"Badger %i", 123];
BOOL equalA = (foo == bar); //< equalAequalA = NO
BOOL equalB = [foo isEqual:bar]; //< equalBequalB = YES
BOOL equalC = [foo isEqualToString:bar]; //< equalCequalC = YES

你可以看到等号操作符与使用等价方法的不同。NSString是一个自己实现了等价比较方法的类,方法名叫做isEqualToString:。使用这个方法的对象一定是一个NSString对象;否则,比较结果就会返回undefined。使用这个方法是比使用isEqual:更快的,它需要别的步骤,因为它不知道比较的对象是什么类型的。

这两个方法的核心等价判断是NSObject的协议方法:

1
2
- (BOOL)isEqual:(id)object;
- (NSUInteger)hash;

这两个NSObject类的方法的默认实现是只有两个对象指向相同的地址时才相等。为了在自定义对象中覆写这些方法,你必须去了解它们的原理。任意两个对象使用isEqual:方法比对结果是相等,那么它们的hash方法也一定会返回相同的值。然而,两个对象的hash方法返回相同的值,它们isEqual:方法的比对结果不一定相等。

例如,考虑下面的类:

1
2
3
4
5
@interface EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, assign) NSUInteger age;
@end

如果所有条件都是相等的,那么两个EOCPerson对象相等。所以isEqual:方法应该这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (BOOL)isEqual:(id)object {
if (self == object) return YES;
if ([self class] != [object class]) return NO;
EOCPerson *otherPerson = (EOCPerson*)object;
if (![_firstName isEqualToString:otherPerson.firstName])
return NO;
if (![_lastName isEqualToString:otherPerson.lastName])
return NO;
if (_age != otherPerson.age)
return NO;
return YES;
}

首先,对比两个对象的指针是否相同。如果指针相同,那么对象一定相等,因为它们是同一个对象。其次,比较两个对象的类。如果两个对象的类不同,那么两个对象不相等。毕竟,一个EOCPerson类不可能等于EOCDog类。当然,你可能希望一个EOCPerson实例等于它一个子类的实例;例如,EOCSmithPerson。这说明在继承层次中,判断相等性是一个常见问题。当你实现了isEqual:方法时,你应该考虑这个问题。最后,没一个属性去检查相等性。如果它们中的任何一个不相等,那么两个对象被认为是不相等;否则,它们是相等的。

接下来实现hash方法。回想一下相等性原则,当两个对象相等,那么它们的哈希码一定相等,但是两个对象的哈希码相等,它们本身却不一定相等。因此如果你复写了isEqual:方法,通常也会覆写hash方法。一个更好的hash方法是像下面这样的:

1
2
3
- (NSUInteger)hash {
return 1337;
}

可是,如果在集合中使用这种办法,这将可能会产生性能问题。因为集合使用哈希表中的哈希码做索引。一个集合的实现是使用哈希存储对象到不同的数组。当给集合添加新对象时,会根据哈希码找到与其对应的数组,对比所有对象,看其是否与新加对象相等。如果相等,说明新加的对象已经在集合里面了。因此,如果所有的对象哈希码都一样,那么在集合中已有1000000个对象时,每次给集合添加对象都会遍历对比这1000000个对象。

另一种实现哈希方法如下:

1
2
3
4
5
6
- (NSUInteger)hash {
NSString *stringToHash =
[NSStringstringWithFormat:@"%@:%@:%i",
_firstName, _lastName, _age];
return [stringToHash hash];
}

这次的哈希算法是通过创建一个字符串并将字符串的哈希码返回。这么做符合约定,因为两个相等的EOCPerson总会生成相等哈希码。然而,这种方法是比返回单一数值的速度慢的,因为你需要创建一个字符串。当给一个集合添加对象时,这仍会导致性能问题,因为要给集合添加对象,仍然需要去计算对象的hash值。

第三种也是最后一种方法是创建一个类似于这样的哈希码:

1
2
3
4
5
6
- (NSUInteger)hash {
NSUInteger firstNameHash = [_firstName hash];
NSUInteger lastNameHash = [_lastName hash];
NSUInteger ageHash = _age;
return firstNameHash ^ lastNameHash ^ ageHash;
}

这种方法是一个这种的办法,既能保持一定的效率,又能使生成的哈希码在一定范围之内。当然,这样还会导致生成相同的哈希码的碰撞,但是至少不会重复太多次。在编写哈希码时,你应该基于当前的业务要求去权衡哈希碰撞频率和创建哈希的性能消耗。

特定类的等同性方法

除了前面说的NSString类提供了特定的等同性方法,还提供特定方法的类还有NSArray(isEqualToArray:)NSDictionary(isEqualToDictionary:),如果对比的对象不是数组或者字典,这两种方法都会抛出异常。Objective-C在编译器并没有严格的类行检测,这样就容易使用错误的对象去对比,所以你应该确定当前对象的类型是正确的。

如果等同性对比比较频繁,那么你可能会创建自己的等同性方法;因此,效率高的重要原因是不需要检查类型。另一个原因是,提供一个指定方法是用于修饰的,它看起来是更好、更易读的,这也是NSString类提供isEqualToString:方法的动机之一。使用此种方法的代码是更易读的,并且也不需要再去检查两个对比对象的类型了。

如果你创建一个特定等同性方法,你需要覆盖isEqual:方法并且通过判断两个对比对象的类型是否相同。如果不相同,就使用父类的实现方法去判断。例如,EOCPerson类的实现应该像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (BOOL)isEqualToPerson:(EOCPerson*)otherPerson {
if (self == object) return YES;
if (![_firstName isEqualToString:otherPerson.firstName])
return NO;
if (![_lastName isEqualToString:otherPerson.lastName])
return NO;
if (_age != otherPerson.age)
return NO;
return YES;
}
- (BOOL)isEqual:(id)object {
if ([self class] == [object class]) {
return [self isEqualToPerson:(EOCPerson*)object];
} else {
return [super isEqual:object];
}
}

等同性对比的深度

当你创建一个等价方法时,你需要决定是检查整个对象的等同性或者仅仅只是一些条件的等同性。NSArray的对比方法是先对比两个数组包含的个数,其次遍历它们,使用isEqual:对比每个元素。如果所有的元素都相等,那么可以认为两个数组相等,这是深度等同性对比。不过有时你仅需要判断一部分数据段是相等的,这对于等同性对比也是可以的。

例如,使用EOCPerson类,如果实例来源于一个数据库,它们可能会添加一个属性用于唯一标示,即使用数据库中的主键。

1
@property NSUInteger identifier;

在这种情况下,可能仅仅需要去检查identifier是否匹配即可,尤其是如果identifier属性特质被声明为只读,那么你可以确定如果两个对象拥有同样的表示符,它们确实代表了相同的对象并且相等。这可以避免对比每个EOCPerson对象的每个数据位,如果你确定它们的标示符相等,那么剩下的数据也相等,因为它们是相同的数据源。

在你的等同性方法中,是否需要检查所有条件的等同性,取决于被检测的对象。只有你才知道在什么情况下两个实例相等。

容器中可变类的等同性

考虑一个重要的情况,在容器中添加一个可变类。一旦你将一个对象添加入一个集合中,那么这个对象的哈希码不应该再改变。前面,我说了集合是根据哈希码去存储对象的。如果哈希码一旦发生变化,集合中的对象将会产生一个错误。为了解决这个问题,你可以确保哈希码不是根据可变部分的对象计算出来的或者保证一旦放入集合中就不再修改它。在第18节,我详细阐述了为了你需要使对象不可变。对于这个问题,下面有一个很好的例子。

通过测试NSMutableSetNSMutableArrays,你可以看到这个问题。开始的时候给集合添加一个数组:

1
2
3
4
5
6
NSMutableSet *set = [NSMutableSetnew];
NSMutableArray *arrayA = [@[@1, @2] mutableCopy];
[set addObject:arrayA];
NSLog(@"set = %@", set);
// Output: set = {((1,2))}

集合中现在有一个对象:带有两个元素的数组。现在使用相同的方法给集合添加一个同样顺序的数组,下面的代码展示了给集合添加一个新的数组以及添加后集合的情况:

1
2
3
4
NSMutableArray *arrayB = [@[@1, @2] mutableCopy];
[set addObject:arrayB];
NSLog(@"set = %@", set);
// Output: set = {((1,2))}

这个集合仍然只保存了一个对象,因为新添加的数组对象是已经在集合中存在的了。现在我们添加一个跟集合中不同的数组:

1
2
3
4
NSMutableArray *arrayC = [@[@1] mutableCopy];
[set addObject:arrayC];
NSLog(@"set = %@", set);
// Output: set = {((1),(1,2))}

果然,集合现在包含了两个数组:一个原来的数组和一个新的数组,因为arrayC与集合中已有的数组不相等。最后,我们修改arrayC使其等于之前集合中的数组:

1
2
3
[arrayC addObject:@2];
NSLog(@"set = %@", set);
// Output: set = {((1,2),(1,2))}

哦,我的天,现在在集合中存在两个相同的数组了。这个集合没有遵守集合的定义,但是我们现在却无法确保这点了。因为我们修改了一个已经在集合中的对象了。如果我们拷贝这个集合,将会更加可怕:

1
2
3
NSSet *setB = [set copy];
NSLog(@"setB = %@", setB);
// Output: setB = {((1,2))}

这个拷贝出来的集合仅有一个对象,这个集合像是先创建一个空的集合,然后逐步从原集合中添加元素得到的。这可能是也可能不是你想要的结果。你可能想要忽视这个错误,按原样复制一个集合。或者你就是想这样做。这两种拷贝情况都是有效的,这进一步说明了刚才的问题,如果修改某个已经加入集合的对象,将会产生不可预料的情况。

举这个例子是想告诉大家,当你修改某个已经加入集合的对象会造成什么后果。这不是说不能这样做,而是你这样做了之后要考虑其后果。

小结

  • 如果想检测等同性,你需要提供isEqual:hash方法。
  • 相同的对象,哈希码一定是相等的;但是哈希码相等,对象却不一定相等。
  • 确定你对比对象同等性时是否需要对比每个对象。
  • 编写哈希算法时,应该使用计算速度快并且碰撞几率低的算法。

使用类簇模式隐藏实现细节

在一个抽象基类中,使用类簇可以很好的隐藏实现细节。在Objective-C的系统框架中,这种模式是非常普遍的。UIKit中有一个例子,就是iOS的UI框架中的UIButton类。创建一个按钮,你会调用下面的方法:

1
+ (UIButton*)buttonWithType:(UIButtonType)buttonType;

返回对象的类型取决于按钮类型。所有的子类都继承自相同的基类,即UIButton类。这样做使用者就不需要关心生成的按钮类型以及按钮如何绘画的具体细节。这一切只需要知道如何创建一个按钮;如何设置标题这样的属性;如何设置点击的动作。

回到按钮绘画的问题上面,它可以通过使用一个类去处理所有按钮的绘画并且基于它们的类型:

1
2
3
4
5
6
7
- (void)drawRect:(CGRect)rect {
if (_type == TypeA) {
// Draw TypeA button
} else if (_type == TypeB) {
// Draw TypeB button
} /* ... */
}

这样看起来是清晰的,但是如果有许多方法基于这个类型的切换去绘制,那么这个方法就会变得非常笨重。一个好的程序员会对这进行重构,通过创建多个子类去实现特定的功能用于对应每种按钮类型。然而这样做需要使用者知道所有的子类。这时就该使用类簇模式了,这种模式提供多个子类,并通过将其实现细节隐藏在基类,用以保持头文件的干净。你不需要创建子类的实例,你通过基类创建它们即可。

创建一个类簇

有一个关于如何创建类簇的例子,考虑一个处理雇员的类,这个类包含雇员的名字和销售额以及每天该做的工作。但是每个雇员每天的工作是不一样的。管理者不关心每个雇员如何完成任务,他只会告诉每个雇员该做什么。

首先,你需要定义这个基类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
typedef NS_ENUM(NSUInteger, EOCEmployeeType) {
EOCEmployeeTypeDeveloper,
EOCEmployeeTypeDesigner,
EOCEmployeeTypeFinance,
};
@interface EOCEmployee : NSObject
@property (copy) NSString *name;
@property NSUInteger salary;
// Helper for creating Employee objects
+ (EOCEmployee*)employeeWithType:(EOCEmployeeType)type;
// Make Employees do their respective day's work
- (void)doADaysWork;
@end
@implementation EOCEmployee
+ (EOCEmployee*)employeeWithType:(EOCEmployeeType)type {
switch (type) {
case EOCEmployeeTypeDeveloper:
return [EOCEmployeeDeveloper new];
break;
case EOCEmployeeTypeDesigner:
return [EOCEmployeeDesigner new];
break;
case EOCEmployeeTypeFinance:
return [EOCEmployeeFinance new];
break;
}
}
- (void)doADaysWork {
// Subclasses implement this.
}
@end

每一个具体的子类继承自基类。例如:

1
2
3
4
5
6
7
8
9
10
@interface EOCEmployeeDeveloper : EOCEmployee
@end
@implementation EOCEmployeeDeveloper
- (void)doADaysWork {
[self writeCode];
}
@end

在这个例子中,这个基类实现了一个基类方法的声明,它通过雇员的类型去创建并初始化不同的实例。这个工厂模式是创建一个类簇的一种方法。

不幸的是,Objective-C语言没有指定一个类是基类的功能。相应的,开发者通常会在这个类中写明类的用法。在这种情况下,头文件没有声明初始化方法,它意味着实例变量并不是立即创建的。另一种确定没使用基类实例的方法是在基类的doADaysWork方法中抛出一个异常。然而,这种做法是非常极端的并且也不是必需的。

还有一个点需要注意的,当使用的对象的类是类簇的一个成员时,要注意它的类型信息(看第14节)。因为你可能认为你创建的是某个类的实例,但实际上确实它的某个子类的实例。在前面的例子中,你可能以为调用[employee isMemberOfClass:[EOCEmployee class]]的结果是YES,但employee其实不是EOCEmployee类初始化的对象,而是其子类,所以它会返回NO

Cocoa中的类簇

系统框架中有许多类簇。大多数的集合类都是类簇,例如NSArray和它对应的可变类NSMutableArray。所以,实际上它有两个基类:一个可变数组和一个不可变数组。它仍然是一个类簇但是却有两个公共接口。不可变类中定义的方法适用于所有的数组,可变类定义的方法仅适用于可变数组。实际上类簇意味着它们在实现自身时可以共享代码,以及可以在创建时把不可变数组变为可变数组,反之亦然。

NSArray中,当你创建一个实例时,它其实是另一个类创建的实例,这个类的用途就是占位。之后这个占位数组会转化为另一个的实例,而那个类则是NSArray的具体子类。这是一个很好的实现,但它超出了本书的讲解范围。

理解像NSArray这样的类是一个类簇是重要的,因为别的原因,你可能会写出这样的代码:

1
2
3
4
id maybeAnArray = /* ... */;
if ([maybeAnArray class] == [NSArray class]) {
// Will never be hit
}

知道了NSArray是一个类簇,可以使你理解上面的代码是错误的,其中if语句的条件永远不可能为真。[maybeAnArray class]返回的类永远不可能是NSArray类,因为NSArray初始化方法返回的变量是由隐藏在类簇内部的某个隐藏类实现的。

注意在类簇中对比实例变量的类是可以的。不适用之前的方法,你应该使用类型信息查询方法。第14节讲述了那些方法。替换之前类对象等同性比较方法,你因该这样写:

1
2
3
4
id maybeAnArray = /* ... */;
if ([maybeAnArray isKindOfClass:[NSArray class]]) {
// Will be hit
}

给类簇添加具体的子类是正常的需求,但是这么做的时候要小心。在Emloyee的例子中,如果没有工厂方法代码,去添加新的子类是不可能的。在Cocoa的类簇中,例如NSArray,它是可以的,但是有一些规则必须去遵守。这几条规则如下:

  • 子类应该继承自类簇的抽象基类。

    NSArray中,它可以继承自不可变数组的基类或者可变数组的基类。

  • 子类应该定义自己的存储方式。

    开发者编写NSArray子类时,经常在这个问题上受阻。子类必须用一个实例标量存储数组中的对象。这看起来与我们预想相反,我们以为NSArray自己会存储它们。但是请记住,NSArray仅仅是一个包在其他隐藏对象外面的壳,它只是定义了一些数组通用的接口。对于这个子类来说,可以是用NSArray来保存其实例。

  • 子类应当覆写父类文档中指明需要覆写的方法。

    每个抽象基类都有一些方法需要子类一定实现的。在NSArray中,需要实现的方法有countobjectAtIndex:。另外的方法,像lastObject,不需要去实现,因为可以使用前两个方法实现这个方法。

实现子类时所需要遵循的规范一般都在类的文档中,所以你首先应该阅读它们。

小结

  • 类簇模式可以使用一套简单的公共接口隐藏实现的细节。
  • 类簇在系统框架中是经常使用的。
  • 在定义类簇的子类时,需要注意遵循基类的协议。如果有文档,那么首先阅读它。

在既有类中使用关联对象存放自定义数据

有时,你想在对象中存储信息。通常,你通常会从那个对象类继承一个子类,然后使用子类去存储。然而,你不能一直这样做,因为类的实例可能是因为某种机制所创建的,并且你不能使这种机制使用你的类创建实例。这时候Objective-C中一个叫做Associated Objects的功能就派上用场了。

对象关联其它对象,并使用一个键去表示它。它们使用存储策略是维持存储对象的内存管理语义。这个存储策略由objc_AssociationPolicy的枚举值定义,表2.1展示了它所包含的值,同事还列出了与之等价的属性的特质(第6节讲述了属性的信息)。

Table 2.1 对象关联类型

关联对象的管理是使用下面语法实现的:

  • void objc_setAssociatedObject(id object, void*key, id value, objc_AssociationPolicy policy)

    使用给定的键和存储策略为对象设置关联对象值。

  • id objc_getAssociatedObject(id object, void*key)

    使用给定的键从对象中获取关联对象值。

  • void objc_removeAssociatedObjects(id object)

    移除对象的所有关联对象。

关联对象的存取方法是类似字典对象这种的,字典通过调用[object setObject:value forKey:key][object objectForKey:key]存取,关联对象同样使用特定的键存储关联的值。但是有一个重要的不同需要知道,关联对象的键是个指针。而字典对象的键是一个字符串,只需要字符串相同就可以了。但是关联对象的键必须是同一个指针才可以匹配。因此,关联对象通常使用全局静态变量作为键。

一个使用关联对象的例子

在iOS开发中,经常会使用UIAlertView类,给用户提供一个基于当前视图的弹窗。当用户点击按钮时有一个代理协议用于处理这个点击事件;然而,使用代理协议需要拆分创建代码和点击事件。由于代码分成两块,所以读起来有些不方便。下面有一个使用UIAlertView的例子,与我们一般写法一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)askUserAQuestion {
UIAlertView *alert = [[UIAlertView alloc]
initWithTitle:@"Question"
message:@"What do you want to do?"
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Continue", nil];
[alert show];
}
// UIAlertViewDelegate protocol method
- (void)alertView:(UIAlertView *)alertView
clickedButtonAtIndex:(NSInteger)buttonIndex
{
if (buttonIndex == 0) {
[self doCancel];
} else {
[self doContinue];
}
}

如果你想在同一个类弹出超过一个弹窗,那么这种写法将会变得更加凌乱,因为你接着需要通过代理方法去检查弹窗的参数,并基于此选择相应的逻辑。如果弹窗创建时就可以决定每个按钮该做什么,那么逻辑就会清楚很多。这时可以使用关联对象去处理。一个解决办法是当创建弹窗时给它设置一个block并且当协议方法触发时调用这个blcok。它实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#import <objc/runtime.h>
static void *EOCMyAlertViewKey = "EOCMyAlertViewKey";
- (void)askUserAQuestion {
UIAlertView *alert = [[UIAlertViewalloc]
initWithTitle:@"Question"
message:@"What do you want to do?"
delegate:self
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"Continue", nil];
void (^block)(NSInteger) = ^(NSInteger buttonIndex){
if (buttonIndex == 0) {
[self doCancel];
} else {
[self doContinue];
}
};
objc_setAssociatedObject(alert,
EOCMyAlertViewKey,
block,
BJC_ASSOCIATION_COPY);
[alert show];
}
// UIAlertViewDelegate protocol method
- (void)alertView:(UIAlertView*)alertView
clickedButtonAtIndex:(NSInteger)buttonIndex
{
void (^block)(NSInteger) =
objc_getAssociatedObject(alertView, EOCMyAlertViewKey);
block(buttonIndex);
}

使用这种方法,创建弹窗的代码和回调结果的代码都在同一个位置,这样比之前的代码更易读,因为你不需要在两处代码之间来回看就可知道为什么使用弹窗了。然而使用这种办法你需要小心一个问题,如果block捕捉了某些变量,这可能会造成循环引用。关于这个问题更具体的信息请看第40节。

如你所见,这种方法是非常好用的,但是你应该确保仅仅是你没有别的办法达到你想做的时再去使用它。如果滥用它,你的代码将会很快超出控制并且难以排查问题。循环引用产生的原因是难以发现的,因为关联对象之间并没有明确的定义,内存管理语义定义在关联时期,而不是声明时期。所以当你使用这种方法时需要小心,不要因为某处可以用它就使用它。另一个实现弹窗的办法是创建子类,将block作为一个属性添加进子类。如果多次使用弹窗时,我认为使用这种办法是优于使用关联对象的。

小结

  • 关联对象提供了一种方法去关联两个对象。
  • 定义关联对象时可使用内存管理语义去模仿属性的拥有或非拥有关系。
  • 当另一种方法不能实现时,才去使用关联对象,因为它可能产生难以查找的异常。

理解objc_msgSend的作用

Objective-C中通过对象调用方法是做的最多的事情之一。在Objective-C术语中,它被称作消息传递。消息有名字或者选择器,带有参数,并且可能会有一个返回值。

因为Objective-C是C的超集,去了解C中的函数调用时一个好的主意。众所周知,C的函数调用时静态绑定,即在编译时就知道函数的调用。例如,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import <stdio.h>
void printHello() {
printf("Hello, world!\n");
}
void printGoodbye() {
printf("Goodbye, world!\n");
}
void doTheThing(int type) {
if (type == 0) {
printHello();
} else {
printGoodbye();
}
return 0;
}

忽略内联函数这种情况,当上述代码编译时,printHello函数和printGoodbye函数是已知的,编译器会直接发出指令去调用函数。在指令集中函数的地址就是有效的硬编码。现在考虑下下述代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#import <stdio.h>
void printHello() {
printf("Hello, world!\n");
}
void printGoodbye() {
printf("Goodbye, world!\n");
}
void doTheThing(int type) {
void (*fnc)();
if (type == 0) {
fnc = printHello;
} else {
fnc = printGoodbye;
}
fnc();
return 0;
}

在这里就需要使用动态绑定了,因为在运行时之前都不知道函数如何调用。在第一个和第二个例子中,编译器生成的指令集是不同的。第一个例子中,函数的调用是在if和else这两种情况中的。第二个例子中,仅有一个调用,不过是有一定代价的,即待调用的函数地址无法硬编码在指令集中,而是要在运行期读取出来。

Obejctvie-C中,当传递消息给对象时,是通过动态绑定机制去决定调用哪个方法的。在底层所有的方法都是普通的C函数,但是当对象接收到信息后,调用哪一个方法完全取决于运行时,甚至可以在程序运行过程去修改它,这使得Objective-C成为一门真正的动态语言。

给对象发送消息看起来是这样的:

1
id returnValue = [someObject messageName:parameter];

在这个例子中,someObject是消息接收者,messageName:是选择器。选择器和参数的组合被称作消息。当编译器看到这个消息时,它将这个消息转化为一条标准的C函数,所调用的函数是消息传递机制中的核心函数,叫做objc_msgSend,其原型如下:

1
void objc_msgSend(id self, SEL cmd, ...)

这是一个参数个数可变的参数,它可以接受两个或两个以上的参数。第一个参数是消息接收者,第二个参数是选择器(SEL的类型是selector),剩下的参数就是消息参数,顺序与它们传递时一致。选择器指的是方法的名字。在计算机术语中,选择器和方法这两个术语经常交替使用。编译器会将上面的例子中的消息转化成这样:

1
2
3
id returnValue = objc_msgSend(someObject,
@selector(messageName:),
parameter);

objc_msgSend函数会依据消息接收者和选择器去调用合适的方法。为了做到这一点,这个函数会去查找消息接收者所属类中的方法实现列表,如果能查找到与接收器名字匹配的方法,就跳转至其实现代码。如果没有找到,这个函数会遍历其继承层次查找这个方法并跳转至其实现代码。如果仍没有找到匹配的方法,那么就执行消息转发。更详细的消息转发机制请看第12节。

这样说来,调用一个方法会需要很多步骤。幸运的是,objc_msgSend会在快速查找表中缓存结果,每个类都有这样一块缓存,所以后面给相同的类和方法组合发送消息是非常快速的。即使这样,快速查找仍然是比静态绑定方法的速度慢的,但是一旦方法缓存,也不会差太多。实际上,消息传递并不是一个应用程序的瓶颈。如果是,你可以编写纯C函数,只在调用需要时,将Objective-C对象状态传递进去。

上面说的仅仅基于确定的消息。另外的边缘情形则需要交给Objective-C运行时的另一些函数:

  • objc_msgSend_stret

    如果发送的消息返回结构体,那么可交由此函数处理。当返回的类型是结构体且能被放进CPU寄存器时,使用这个函数处理。如果寄存器不能容纳返回类型,那么就由另一个函数执行派发。此时,那个函数会通过分配在栈上的某个变量来处理消息所返回的结构体。

  • objc_msgSend_fpret

    如果发送消息的返回值是浮点数,那么可交由此函数处理。某些处理器调用函数时,需要对浮点型寄存器进行特殊处理,这意味着标准的objc_msgSend是不适合的。这个函数存在的作用是处理x86等架构中的一些特殊的情况。

  • objc_msgSendSuper

    发送消息给父类,例如[super message:parameter],使用这个方法。它也有两个等价于objc_msgSend_stretobjc_msgSend_fpret的函数,用于处理发给父类的消息。

刚才提到过,objc_msgSend等函数一旦搜寻到正确的方法实现就跳转至其实现代码。之所以能这样做,是因为每个Objective-C对象方法都能看做是一个简单的C函数,它的原型如下:

1
<return_type> Class_selector(id self, SEL _cmd, ...)

函数的名字可能不像上面那样,但我用类和选择器去组合它,仅仅是为了说明它的原理。每个类里面都有一张表,其中的指针会指向函数,以选择器名字为键去查找。objc_msgSend等函数正是通过这张表来寻找应该执行的方法并跳至其实现的。注意原型和objc_msgSend函数很相似,但这不是巧合。它简化跳转方法并且可以更好的使用尾调用优化。

当一个方法的最后一行是调用另一个函数,那么就可以使用尾调用优化。编译器可产生跳转至下一个函数的指令码,也不用生成新的栈帧。这仅当一个函数最后的操作是调用另一个函数且不需要使用返回值做任何事情,才能执行尾调用优化。这项优化对objc_msgSend是非常重要的,因为没有它,每次调用Objective-C方法,栈将会在栈踪迹中显示所有objc_msgSend调用的函数。并且,也将会经常发生栈溢出现象。

实际上,你在写Objective-C代码时,不需要担心这些问题,但是理解这些操作的本质对开发来说是有益的。如果你理解了在发送消息时发生了什么,你可以了解你的代码是如何执行的并且在调试时,也能理解为什么栈回溯中总会出现objc_msgSend函数。

小结

  • 一个消息由一个接收者,一个选择器,参数组成。给对象发送消息相当于对象调用方法。
  • 当调用时,所有的消息都需要通过动态消息发送系统来处理,它会查找方法的实现然后运行它们。

理解消息转发

第11节解释了理解对象消息机制是重要的。第12节探讨当对象遇到无法处理的消息时发生了什么。类可以理解消息仅仅是因为它实现了相对应的方法。类接收到一个无法理解的信息会发生错误,但它并不是发生在编译时。因为方法是在运行时被添加到类中的,所以编译器并不知道对应的方法是否存在。当某个类接受到一个它不理解的消息,对象通过使用消息转发,一个允许开发者预处理的设计。开发者可以通过它去处理那些类无法理解的消息。

即使你不知道消息转发,但你可能已经遇到过由消息转发流程处理的消息了。每次你在控制台看到这样的信息,它是因为你给某个对象传递了消息但是那个对象无法处理它,所以它通过转发机制,将消息转发给了NSObject的默认实现。

1
2
3
4
5
-[__NSCFNumber lowercaseString]: unrecognized selector sent to
instance 0x87
*** Terminating app due to uncaught exception
'NSInvalidArgumentException', reason: '-[__NSCFNumber
lowercaseString]: unrecognized selector sent to instance 0x87'

这是从NSObjectdoesNotRecognizeSelector:方法抛出的一个异常,它告诉你这个消息的接收者类型是__NSCFNumber,并且这个接收者无法理解这个叫做lowercasrString的选择器。在上述示例中这并不奇怪,因为NSNumber是没有那个方法的(__NSCFNumber是为了实现无缝桥接而使用的内部类,当你初始化一个NSNumber实例时,它会被创建)。在这个例子中,应用程序最后以崩溃而告终,但是你可以在你的类中拦截到转发机制去执行你想要的逻辑替代崩溃。

转发路径是分为两大阶段的。第一个阶段是给接收者的类一个机会去动态的添加一个方法,用于处理未知的选择器。这叫做动态方法解析。第二个阶段涉及完整的消息转发机制。如果运行时间已经过了第一阶段,那么接收者就无法再以添加方法的方式去相应选择器。所以它告诉接收者自己去尝试处理。这也分为两步。首先,它会问别的对象是否接受这个消息。如果有,运行时会转移消息并结束消息转发。如果没有替代的接收者,则启动完整的转发机制,使用NSInvocation对象去把所有与消息有关的细节包装起来并给开发者最后一个机会去处理它。

动态方法解析

当一个对象接收到一个它不能理解的方法时,在它所属类中调用的第一个方法是:

1
+ (BOOL)resolveInstanceMethod:(SEL)selector;

这个方法带有一个当前类无对应实现的选择器并且返回一个布尔值指示是否在运行期有可以对应选择器的实例方法添加到本类。因此,在触发其余转发机制之前这个类有第二次机会去添加对应的实现。还有一个类似的方法,叫做resolveClassMethod:,当一个未实现的方法是类方法而不是实例方法时会调用它。

使用这种方法依赖于这个方法实现已经是可用的,准备动态的插入类中。这个方法经常被用做实现@dynamic属性(看第6节),例如在CoreData中访问NSManagedObjects属性,因为访问方法需要去手动实现,这样属性才可以在编译时被知道。

这样一个resolveInstanceMethod:实现,对于使用@dynamic属性看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
id autoDictionaryGetter(id self, SEL _cmd);
void autoDictionarySetter(id self, SEL _cmd, id value);
+ (BOOL)resolveInstanceMethod:(SEL)selector {
NSString *selectorString = NSStringFromSelector(selector);
if ( /* selector is from a @dynamic property */ ) {
if ([selectorString hasPrefix:@"set"]) {
class_addMethod(self,
selector,
(IMP)autoDictionarySetter,
"v@:@");
} else {
class_addMethod(self,
selector,
(IMP)autoDictionaryGetter,
"@@:");
}
return YES;
}
return [super resolveInstanceMethod:selector];
}

首先将选择器转为一个字符串,并且检查它是否是一个setter方法。如果前缀带有set字符,假定它是一个setter方法;否则,假定它是一个getter方法。在每种情况下,都会对给定选择器的类添加一个方法,它指向一个C函数的实现。在这些C函数中将会通过代码来控制类使用某种数据结构去存储属性数据。例如,在CoreData中,这些方法将通过后端数据库去检索或更新值。

备用接收者

第二步处理未知选择器的方法是询问接收者是否有一个可用的替代接收者去处理相应的消息。这个方法是这样的:

1
- (id)forwardingTargetForSelector:(SEL)selector

传递未知的选择器并且返回符合预期的接收者,当未找到符合预期的接收者,将返回nil。这个方法可以让我们通过组合来模拟多重继承的某些特性。一个对象内部可能还有一系列别的对象,在这个方法中它可以返回能够处理的选择器相关的内部对象,并使其在外界看来像是它亲自处理一样。

注意,我们无法再这一步对消息做出改变。如果消息需要在发送给备用接收者之前做改变,那么我们需要使用完整的转发机制。

完整转发机制

如果转发算法已经走到这一步,那么能做的事仅有使用完整的转发机制了。首先创建一个NSInvocaton对象,用于包含不能处理的消息的所有细节。这个对象包含选择器,目标接收者,和参数。在触发NSInvocaton对象可时,这将导致消息派发系统去将消息派发给指定的对象。

此步骤调用的转发方法:

1
- (void)forwardInvocation:(NSInvocation*)invocation

一个简单的实现是改变这个对象的目标并触发它。这样的实现与备用接收者是等价的,但是很少有人使用这么简单的实现。更有用的实现是可以在调用前通过某些办法改变这个消息,比如拼接另一个参数或者改变选择器。

实现此方法时,如果发现调用不是由该类处理,那么应该调用它的父类去处理。这意味着在继承链上的所有父类都有机会去处理这个调用,直至NSObject的实现。如果最后该消息仍没有处理,那么仍会调用doesNotRecognizeSelector:抛出异常。

完整的转发机制图

图2.2这张流程图描述了消息转发机制处理消息的各个步骤。

在每一步,接收者都有机会去处理消息。每一步处理代价都比上一步大。最好的处理时机在第一部,因为方法在运行时被添加将会被运行时缓存,当你使用同一个类的实例再次调用时,它不需要再走转发机制就可以找到这个选择器了。如果能找到一个备用接收者,那么在第二步处理是优于第三步处理的。在第三步中,仅是修改有关的调用目标,那在第二步做这个是比第三步更简单的,并且也不需要再去创建NSInvocaton对象。

动态方法解析的完整示例

为了说明如何使用消息转发机制,下面的例子展示了使用动态方法解析显现@dynamic属性。考虑一个对象允许你存储任何对象进去,类似一个字典,但需要通过属性提供存取方法。这个类的设计思路是你可以添加属性定义并使用@dynamic声明它,类将处理存储和获取方法。这听起来是不是很不错?

这个类的接口大概是这样的:

1
2
3
4
5
6
7
8
#import <Foundation/Foundation.h>
@interface EOCAutoDictionary : NSObject
@property (nonatomic, strong) NSString *string;
@property (nonatomic, strong) NSNumber *number;
@property (nonatomic, strong) NSDate *date;
@property (nonatomic, strong) id opaqueObject;
@end

在这个例子中,这些属性具体是什么是不重要的。我之所以写这么多类型只是为了展示这个功能的强大。在类的内部,每一个属性将都存放在字典中,所以类开始的实现是下面这样的,包含使用@dynamic声明属性,这样这些属性的实例变量和存取方法就不会自动生成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#import “EOCAutoDictionary.h"
#import <objc/runtime.h>
@interface EOCAutoDictionary ()
@property (nonatomic, strong) NSMutableDictionary *backingStore;
@end
@implementation EOCAutoDictionary
@dynamic string, number, date, opaqueObject;
- (id)init {
if ((self = [super init])) {
_backingStore = [NSMutableDictionary new];
}
return self;
}

然后是本例重要的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ (BOOL)resolveInstanceMethod:(SEL)selector {
NSString *selectorString = NSStringFromSelector(selector);
if ([selectorString hasPrefix:@"set"]) {
class_addMethod(self,
selector,
(IMP)autoDictionarySetter,
"v@:@");
} else {
class_addMethod(self,
selector,
(IMP)autoDictionaryGetter,
"@@:");
}
return YES;
}
@end

首次调用一个位于EOCAutoDictionary实例中的属性时,运行时找不到对应的选择器,因为它们即没有直接实现也没有自动合成。例如,假如要向opaqueObject对象写入信息,那么将会调用setOpaqueObject:方法。同理,在读取该属性时,会调用opaqueObject方法。resolveInstanceMethod:方法会检测方法是不是含有set前缀,以此区分settergetter方法。在每种情况下,都会向类中增加一个方法去处理选择器,这两个方法分别是autoDictionarySetterautoDictionaryGetter函数的指针。这时就可以使用运行时的class_addMethod方法,给类动态的添加方法,用于处理对应的选择器,并且带有所添加方法的指针。最后一个参数表示实现方法的类型编码。在本例中,编码开头的字符表示方法的返回值类型,后续字符则表示其所接受的各个参数。

getter函数的实现:

1
2
3
4
5
6
7
8
9
10
11
id autoDictionaryGetter(id self, SEL _cmd) {
// Get the backing store from the object
EOCAutoDictionary *typedSelf = (EOCAutoDictionary*)self;
NSMutableDictionary *backingStore = typedSelf.backingStore;
// The key is simply the selector name
NSString *key = NSStringFromSelector(_cmd);
// Return the value
return [backingStore objectForKey:key];
}

最后,setter函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void autoDictionarySetter(id self, SEL _cmd, id value) {
// Get the backing store from the object
EOCAutoDictionary *typedSelf = (EOCAutoDictionary*)self;
NSMutableDictionary *backingStore = typedSelf.backingStore;
/** The selector will be for example, "setOpaqueObject:".
* We need to remove the "set", ":" and lowercase the first
* letter of the remainder.
*/
NSString *selectorString = NSStringFromSelector(_cmd);
NSMutableString *key = [selectorString mutableCopy];
// Remove the ':' at the end
[key deleteCharactersInRange:NSMakeRange(key.length - 1, 1)];
// Remove the 'set' prefix
[key deleteCharactersInRange:NSMakeRange(0, 3)];
// Lowercase the first character
NSString *lowercaseFirstChar =
[[key substringToIndex:1] lowercaseString];
[key replaceCharactersInRange:NSMakeRange(0, 1)
withString:lowercaseFirstChar];
if (value) {
[backingStore setObject:value forKey:key];
} else {
[backingStore removeObjectForKey:key];
}
}

使用EOCAutoDictionary的方法很简单:

1
2
3
4
EOCAutoDictionary *dict = [EOCAutoDictionary new];
dict.date = [NSDate dateWithTimeIntervalSince1970:475372800];
NSLog(@"dict.date = %@", dict.date);
// Output: dict.date = 1985-01-24 00:00:00 +0000

其它属性在字典中的实现也类似于日期属性,如果要添加新的属性,也可以使用@property定义,@dynamic声明它。在iOS的CoreAnimation框架中,CALayer类也是用来类似的方法。这是的CALayer成为可以兼容键值对容器的类,这意味着你可以随意添加键值对,并以属性的方法访问它。于是,开发者就可以向其中新增自定义的属性了,这些属性值的存储工作由基类直接负责,我们只需在CALayer的子类中定义新属性即可。

小结

  • 如果对象无法响应选择器,则进入消息转发流程。
  • 运行时的动态方法解析可以使我们给类添加我们需要使用的方法。
  • 对象可以把无法处理的选择器交给其他对象去处理。
  • 当前述步骤未处理选择器时,启动完成的转发机制。

考虑使用方法交换去调式不透明方法

Objective-C中,当给对象发送消息时,它的一系列调用是发生在运行期的,第11节详细阐述了这个过程。你可能会在运行时修改给定选择器的对应方法。这是可以的。这个功能有巨大的用途,你可以使用它修改类中的方法对于某些你没有代码的方法,不需要子类和重载方法。因此,这个新的函数可以被所有类的实例使用而不仅是重载方法的子类实例。这种方法通常被称为Method Swizzling

一个类的方法列表中包含一个选择器名字列表用于映射,告诉动态消息系统在哪里找到给定方法的实现。这个实现是作为函数指针被存储的,叫做IMPs,如下面的原型:

Figure 2.3 NSString的选择器表

1
id (*IMP)(id, SEL, ...)

NSString类可以响应这些选择器的调用lowercaseStringuppercaseStringcapitalizedString以及其它方法。每一个选择器指向一个不同的实现,类似于图2.3。

Objective-C运行时暴露的一些方法可以操作这张表。你可以给列表添加选择器,改变实现指向,或者交换两个选择器的实现。执行其中的一些操作,类方法表可能像图2.4。

Figure 2.4 执行了一些操作后,NSString选择器的表

添加了一个新的叫做newSelector选择器,改变了capitalizedString的实现,并且交换了lowercaseStringuppercaseString的实现。上述修改均无须编写子类,只要修改了方法表的布局,就会反映到程序中所有的NSString实例之上。怎么样,这是一个强大的功能吧。

这节的话题会讲述两个方法交换的过程。这样做可以为已有方法添加新功能。在讲述如何给已有方法添加功能之前,我将先讲述如何去交换两个已经存在的方法。交换方法实现,你应该使用下面的函数:

1
void method_exchangeImplementations(Method m1, Method m2)

这个函数中的参数用于交换。它们可以通过下面的函数获取:

1
Method class_getInstanceMethod(Class aClass, SEL aSelector)

这个方法使用给定的选择器在类中去检索方法。在前面的例子中,交换lowercaseStringuppercaseString的实现,需要执行下面的代码:

1
2
3
4
5
6
7
Method originalMethod =
class_getInstanceMethod([NSStringclass],
@selector(lowercaseString));
Method swappedMethod =
class_getInstanceMethod([NSStringclass],
@selector(uppercaseString));
method_exchangeImplementations(originalMethod, swappedMethod);

从这时起,所有的NSString实例调用lowercaseString方法时,都会调用uppercaseString的实现,反之亦然:

1
2
3
4
5
6
7
8
9
NSString *string = @"ThIs iS tHe StRiNg";
NSString *lowercaseString = [string lowercaseString];
NSLog(@"lowercaseString = %@", lowercaseString);
// Output: lowercaseString = THIS IS THE STRING
NSString *uppercaseString = [string uppercaseString];
NSLog(@"uppercaseString = %@", uppercaseString);
// Output: uppercaseString = this is the string

上面展示了如何交换两个方法实现,但是在实际使用中,简单的交换两个实现不是非常有用的。毕竟,你为什么要交换lowercaseStringuppercaseString的实现,它们已经做的不错了。你没有任何理由去交换它们。但是同样的方法可以用来给已有的方法添加新功能。假如你想记录调用lowercaseString时的某些信息。同样的办法可以达到这个目的。它需要你去实现一个新的方法并包含你想要的功能,然后通过交换去替代掉原有方法。

可以使用category去添加的方法,如下:

1
2
3
@interface NSString (EOCMyAdditions)
- (NSString*)eoc_myLowercaseString;
@end

这个方法与原有的lowercaseString方法交换,交换后的方法表如图2.5。

新方法的实现如下:

1
2
3
4
5
6
7
@implementation NSString (EOCMyAdditions)
- (NSString*)eoc_myLowercaseString {
NSString *lowercase = [self eoc_myLowercaseString];
NSLog(@"%@ => %@", self, lowercase);
return lowercase;
}
@end

这看起来像是会陷入递归调用的死循环,但是要记住它们的实现已经交换了。所以在运行时,当查找eoc_myLowercaseString选择器时,它会调用lowercaseString的实现。最后,交换两个方法的实现,像下面这样使用:

1
2
3
4
5
6
7
Method originalMethod =
class_getInstanceMethod([NSString class],
@selector(lowercaseString));
Method swappedMethod =
class_getInstanceMethod([NSString class],
@selector(eoc_myLowercaseString));
method_exchangeImplementations(originalMethod, swappedMethod);

从现在起,所有的NSString实例调用lowercaseString方法时,都会在日志中打印出如下信息:

1
2
3
NSString *string = @"ThIs iS tHe StRiNg";
NSString *lowercaseString = [string lowercaseString];
// Output: ThIs iS tHe StRiNg => this is the string

通过这个方案,开发者可以对那些无法知道实现的方法增加日志输出,这对调试来时是非常有用的。然而此方法也应只在调试模式使用它。很少有人在全局类中使用它。不要因为你能使用它就去用它。过多的使用会使你的代码难以阅读和难以控制。

小结

  • 在运行时可以给类中添加方法或者替换某个指定的选择器的实现。
  • 使用一个方法实现去替代另一个方法实现,叫做Method Swizzling,开发者通常用其为已有方法添加功能。
  • 在调试模式下,通过运行时去修改方法实现是好的做法,但是不能滥用。

理解类对象

Objective-C实际上是一门极其动态的语言。第11节讲述了在运行时如何查找一个方法的实现,第12节讲述了当一个类不能响应一个确定的选择器时的转发机制。但是消息接收者是什么:那个对象自身?runtime怎么知道那个对象的类型?在编译时对象的类型并未绑定,而是在运行时确定的。此外,一个特殊的类型id,它可以表示任意的Objective-C对象类型。通常,指定的对象类型是已知的,所以编译器才能在它认为接收者无法接收某条消息时发出警告。相反的,当对象类型是id时,编译器将假定它可以相应所有的消息。

从第12节你可以知道,编译器无法知道一个确定类型到底能理解多少选择器,因为它们可以在运行时动态添加。然而,即使知道可能会在运行时添加方法,编译器也觉得可以在某个头文件中看到方法原型的定义,这样它可以知道完整的方法签名,用于生成消息派发所需的正确代码。

在运行时检查对象的类型也被称作内省,这是一个强大且有用的功能,它作为NSObject协议的一部分内置在Foundation框架中,凡是由公共根类(NSObject与NSProxy)继承来的类都要遵守它。使用这些方法而不是直接对比对象的类是明智的,我将在后面讲述为什么不要直接对比。不过在介绍类型信息查询技术之前,我们先讲一些基础知识,看看Objective-C对象的本质是什么。

每一个Objective-C对象实例指向一块内存区域。这就是为什么当你声明一个变量时,看到类型后面有一个*

1
NSString *pointerVariable = @"Some string";

编过C语言程序的人都知道这是什么意思。对于没写过C语言的程序员来说,pointerVariable是一个存放内存地址的变量,而NSString自身的数据就存于那个地址中。因此这个变量指向NSString实例。所有的Objective-C对象都是这样的;如果想在栈上初始化一个对象,你将会收到一个来自编译器的错误:

1
2
String stackVariable = @"Some string";
// error: interface type cannot be statically allocated

通用的对象类型id,它本身就是一个指针,所以你可以这样使用它:

1
id genericTypedString = @"Some string";

这个定义的语义与NSString*是相同的。它们的区别在于指明类型的情况下,对于这个实例如果你尝试调用一个类中不存在的方法,编译器可以帮助你检查,并且发出警告信息。

runtime的头文件中,所有的对象的数据结构都是这样定义的,id类型本身也在这定义:

1
2
3
typedef struct objc_object {
Class isa;
} *id;

因此,每个对象的首个成员是Class类型的变量。这个变量定义了对象所属的类型,常常被称为isa指针。例如,刚才的例子中的对象是一个NSString。类对象也定义在runtime的头文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct objc_class *Class;
struct objc_class {
Class isa;
Class super_class;
const char *name;
long version;
long info;
long instance_size;
struct objc_ivar_list *ivars;
struct objc_method_list **methodLists;
struct objc_cache *cache;
struct objc_protocol_list *protocols;
};

这个结构体存放类的元数据,例如类实例的方法实现和实例变量。实际上这个结构体也有一个isa指针,并且是第一个变量,它意味着Class自身也是一个Objective-C对象。这个结构体也有另一个变量,叫做super_class,这个是类的父类。类的类型是另一个类,叫做元类,用来表述类对象本身所具备的元数据。类方法就定义在元类中,因为它可以理解成类对象的实例方法。每个类仅有一个类对象,每个类对象也只有一个元类。

一个叫做someClass的类继承自NSObject,继承链如图2.6。

Figure 2.6 SomeClass的实例继承链,它继承自NSObject,包含元类的继承。

super_class指针确立了继承关系,isa指针则描述了实例所属的类。你可以通过操作这个布局来执行内省(检查对象的类型)。你可以通过它找到一个对象是否可以响应某个确定的选择器并且遵循某个确定的协议,并且确定对象所属类的继承信息。

检查类的继承

内省方法可以用作去检查类的继承。你可以使用isMemberOfClass:判断一个对象是否是某个确定类的实例,或者使用isKindOfClass:去检查某个对象是否是某个确定类或者任何继承自它的类的实例。例如:

1
2
3
4
5
NSMutableDictionary *dict = [NSMutableDictionary new];
[dict isMemberOfClass:[NSDictionary class]]; ///< NO
[dict isMemberOfClass:[NSMutableDictionary class]]; ///< YES
[dict isKindOfClass:[NSDictionary class]]; ///< YES
[dict isKindOfClass:[NSArray class]]; ///< NO

像这种内省类型的原理是通过isa指针获得对象的类,并且使用super_class去遍历整条继承链。由于对象类型是动态的,所以这个功能是非常重要的。Objective-C与你了解的其他语言不同,在Objective-C中,必须查询类型信息,才能完全了解对象的真实类型。

由于Objective-C使用动态类型绑定,所以查询对象所属类的功能是非常有用的。当你从集合中获取对象时,内省是非常常用的,因为它们不是强类型,意思是指当对象是从集合中获取的时候,它们通常是id类型。如果需要知道具体的类型,那么就可以使用内省:例如,想根据数组中存储的对象生成以逗号分隔的字符串,并将其存至文本文件,就可以使用下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (NSString*)commaSeparatedStringFromObjects:(NSArray*)array {
NSMutableString *string = [NSMutableString new];
for (id object in array) {
if ([object isKindOfClass:[NSString class]]) {
[string appendFormat:@"%@,", object];
} else if ([object isKindOfClass:[NSNumber class]]) {
[string appendFormat:@"%d,", [object intValue]];
} else if ([object isKindOfClass:[NSData class]]) {
NSString *base64Encoded = /* base64 encoded data */;
[string appendFormat:@"%@,", base64Encoded];
} else {
// Type not supported
}
}
return string;
}

去检查类对象的等价性也是可以的。如果你要这样做,那么可以使用==操作符而不要使用你对比对象时常用的isEqual:方法(看第8节)。理由是在一个应用程序中,没个类都是单例,并且每个类仅会有一个类对象存在。因此,另外一种可以准确判断对象是否为某类实例的办法如下:

1
2
3
4
id object = /* ... */;
if ([object class] == [EOCSomeClass class]) {
// 'object' is an instance of EOCSomeClass
}

即使能这样做,你也应该使用内省方法去判断而不是直接调用==操作符,因为内省方法可以完整的处理消息转发(看第12节)的情况。考虑下一个对象将它的所有选择器都转发给另一个对象了,这样的对象叫做代理,并且对于这些类似的对象都以NSProxy为根类。

通常情况下,假如这样的代理对象调用class方法,那将返回代理类(例如:NSProxy的子类),而非接受的代理的对象所属的类。然而,如果是这样的内省方法,例如isKindOfClass:,那么代理对象会把这个方法转发给接受代理的对象。这意味着这条消息的返回值与直接在接受代理对象上面查询的结果是一样的。因此,这样检查出来的类对象与调用class方法返回的类对象不同,class方法所返回的类表示发起代理的对象,而非接受代理的对象。

小结

  • 每个实例都有一个isa指针,用于表明它的类型与指向类对象,而类对象构成了类的继承链。
  • 当在编译时不能确实对象类型时,应该使用内省去确定对象的具体类型。
  • 应该总是使用内省去检查对象类型,而不是直接对比类对象,因为对象可能实现了消息转发。